Even if you haven’t worked with TypeScript yet, you probably heard about it. It’s seen vast adoption in the React world in the past years. As of today, almost all React jobs seem to require TypeScript knowledge.
So, many React devs ask themselves: Do I really need to learn TypeScript?
I get it, you have lots on your plate already. And especially if you’re still trying to break into the industry you’re likely overwhelmed with all the stuff to learn. But take it from this dev:
Learning TypeScript is easily the best investment:
In short, your developer experience will skyrocket. Still, you’ll likely have a rough time at first. Lots of strange errors and coding that feels like you have one hand tied behind your back (plus 4 of the remaining fingers glued together).
This page aims at getting you up to speed quickly without overwhelming you with all the details. You’ll get a rather minimal introduction to using TypeScript with React. To make the knowledge stick you can find interactive code exercises after almost every section.
Since this easily becomes a rather dry topic we loosely follow the anti-hero of this story: Pat, a nasty CTO.
Note that the code editor used for the exercises on this page is quite new. If you run into problems I’d appreciate a bug report at bugs@profy.dev.
Their name is diminishing, but the three primitives are at the core of all types.
string // e.g. "Pat"boolean // e.g. truenumber // e.g. 23 or 1.99
Arrays can be built from primitives or any other type.
number[] // e.g. [1, 2, 3]string[] // e.g. ["Lisa", "Pat"]User[] // custom type e.g. [{ name: "Pat" }, { name: "Lisa" }]
Objects are everywhere and they can be really powerful. Just as in this example:
const user = {firstName: "Pat",age: 23,isNice: false,role: "CTO",skills: ["CSS", "HTML", "jQuery"]}
What a “CTO”! The TypeScript type describing this object looks like this:
type User = {firstName: string;age: number;isNice: boolean;role: string;skills: string[];}
Obviously, people don’t only consist of primitives.
type User = {firstName: string;...friends: User[];}
But looking at our “CTO”, it’s good that we can make fields optional. Pat clearly chose his career over his friends:
const user = {firstName: "Pat",age: 23,isNice: false,role: "CTO",skills: ["CSS", "HTML", "jQuery"],friends: undefined}
As hard as this may be in the life of Pat, in TypeScript it’s as simple as adding a ?
:
type User = {firstName: string;...friends?: User[];}
Remember, we defined the User.role
field as a string
.
type User = {...role: string,}
Pat as the “CTO” isn’t happy about that. He knows that the string
type isn’t restrictive enough. His employees shouldn’t be able to select any role they want.
Enums to his rescue!
enum UserRole {CEO,CTO,SUBORDINATE,}
This is much better! But Pat isn’t stupid: he knows that the values of this enum internally are only numbers. Even though the CEO is at the top (kiss-ass Pat) its numeric value is 0
. Zero! The CTO is 1. And the subordinate is a 2?
Seems inappropriate. How come everyone's more valuable than the leadership?
Luckily, we can use string values for our enums instead.
enum UserRole {CEO = "ceo",CTO = "cto",SUBORDINATE = "inferior-person",}
This is very helpful when you want to get a message across (looking at you Pat). But it can also be useful when working with strings coming from an API.
Anyway, Pat is pleased now. He can safely assign anyone their appropriate role.
enum UserRole {CEO = "ceo",CTO = "cto",SUBORDINATE = "inferior-person",}type User = {firstName: string;age: number;isNice: boolean;role: UserRole;skills: string[];friends?: User[];}const user = {firstName: "Pat",age: 23,isNice: false,role: UserRole.CTO, // equals "cto"skills: ["CSS", "HTML", "jQuery"]}
What’s likely the favorite activity of any person in power? Not sure, but Pat certainly loves to demonstrate his power by firing a bunch of losers.
So let’s make Pat happy and write a function that increases his firing performance.
Typing Function Parameters
We have three ways to identify the person to be fired. First, we can use multiple parameters.
function fireUser(firstName: string, age: number, isNice: boolean) {...}// alternatively as an arrow functionconst fireUser = (firstName: string, age: number, isNice: boolean) => {...}
Second, by we can wrap all of the above parameters in an object and define the types inline.
function fireUser({ firstName, age, isNice }: {firstName: string;age: number;isNice: boolean;}) {...}
And finally (since the above isn’t very readable) we can also extract the type. Spoiler alert: this is what we see with React component props a lot.
type User = {firstName: string;age: number;role: UserRole;}function fireUser({ firstName, age, role }: User) {...}// alternatively as arrow functionconst fireUser = ({ firstName, age, role }: User) => {...}
Typing Function Return Values
Simply firing a user might not be enough for Pat. Maybe he wants to insult them even more? So it might be a good idea to return the user from the function.
Again there are multiple ways of defining the return type. First we can add : User
after the closing bracket of the parameter list.
function fireUser(firstName: string, age: number, role: UserRole): User {// some logic to fire that loser ...return { firstName, age, role };}// alternatively as an arrow functionconst fireUser = (firstName: string, age: number, role: UserRole): User => {...}
This enforces that the correct type is returned. If we’d try to return something else (e.g. null
) TypeScript wouldn’t let us.
If we don’t want to be that strict, we can also let TypeScript infer the return type.
function fireUser(user: User) {// some logic to fire that loser ...return user;}
Here we simply return the input parameters. This could also be the return value of the “firing” logic. But since the type of the returned value user
is clear TypeScript automatically knows the return type of the function.
So with TypeScript less is often better. You don’t need to define types everywhere but can often rely on type inference. When in doubt simply hover with your mouse cursor on top of the variable or function in question as shown in the screenshot above.
Here are a few more things that we don’t cover here but you’ll encounter rather sooner than later.
When it comes to React and TypeScript the basics from above sections are usually sufficient. After all, React function components and hooks are simple functions. And props are just objects. In most cases, you don’t even need to define any types as TypeScript knows how to infer them.
For the most part, you don’t need to define the return type of your components. You’d definitely type the props (as we’ll see in a bit). But a simple prop-less component doesn’t need any types.
function UserProfile() {return <div>If you're Pat: YOU'RE AWESOME!!</div>}
The return type is JSX.Element
as you can see in this screenshot.
The great thing: If we mess up and return anything from a component that is not valid JSX, TypeScript warns us.
In this case, a user
object isn’t valid JSX so we get an error:
'UserProfile' cannot be used as a JSX component.Its return type 'User' is not a valid JSX element.
The UserProfile
pays our “CTO” Pat a nice compliment. But it shows the same message for every user and that feels like an insult. Obviously, we need props.
enum UserRole {CEO = "ceo",CTO = "cto",SUBORDINATE = "inferior-person",}type UserProfileProps = {firstName: string;role: UserRole;}function UserProfile({ firstName, role }: UserProfileProps) {if (role === UserRole.CTO) {return <div>Hey Pat, you're AWESOME!!</div>}return <div>Hi {firstName}, you suck!</div>}
If you’re more into arrow functions the same component looks like this.
const UserProfile = ({ firstName, role }: UserProfileProps) => {if (role === UserRole.CTO) {return <div>Hey Pat, you're AWESOME!!</div>}return <div>Hi {firstName}, you suck!</div>}
A final note: In the wild, you find a lot of code that uses React.FC
or React.FunctionComponent
to type components. This is not recommended anymore though.
// using React.FC is not recommendedconst UserProfile: React.FC<UserProfileProps>({ firstName, role }) {...}
As you know, in React we often pass around callback functions as props. So far we haven’t seen fields of type function. So let me quickly show you:
type UserProfileProps = {id: string;firstName: string;role: UserRole;fireUser: (id: string) => void;};function UserProfile({ id, firstName, role, fireUser }: UserProfileProps) {if (role === UserRole.CTO) {return <div>Hey Pat, you're AWESOME!!</div>;}return (<><div>Hi {firstName}, you suck!</div><button onClick={() => fireUser(id)}>Fire this loser!</button></>);}
Note: void
is the return type of the function and stands for “nothing”.
Remember: we can make a field optional by marking it with ?
. We can do the same with optional props. Here the role
isn’t required.
type UserProfileProps = {age: number;role?: UserRole;}
If we want to have a default value for an optional prop we can assign it when destructuring the props.
function UserProfile({ firstName, role = UserRole.SUBORDINATE }: UserProfileProps) {if (role === UserRole.CTO) {return <div>Hey Pat, you're AWESOME!!</div>}return <div>Hi {firstName}, you suck!</div>}
The most used hook in React is useState()
. And in many cases, you don’t need to type it. If you use an initial value TypeScript can infer the type.
function UserProfile({ firstName, role }: UserProfileProps) {const [isFired, setIsFired] = useState(false);return (<><div>Hi {firstName}, you suck!</div><button onClick={() => setIsFired(!isFired)}>{isFired ? "Oops, hire them back!" : "Fire this loser!"}</button></>);}
Now we’re safe. If we try to update the state with anything else than a boolean we get an error.
In other cases, TypeScript can’t infer the type properly from the initial value though. For example:
// TypeScript doesn't know what type the array elements should haveconst [names, setNames] = useState([]);// The initial value is undefined so TS doesn't know its actual typeconst [user, setUser] = useState();// Same story when we use null as initial valueconst user = useState(null);
useState()
is implemented with a so-called generic type. We can use this to type our state properly:
// the type of names is string[]const [names, setNames] = useState<string[]>([]);setNames(["Pat", "Lisa"]);// the type of user is User | undefined (we can either set a user or undefined)const [user, setUser] = useState<User>();setUser({ firstName: "Pat", age: 23, role: UserRole.CTO });setUser(undefined);// the type of user is User | null (we can either set a user or null)const [user, setUser] = useState<User | null>(null);setUser({ firstName: "Pat", age: 23, role: UserRole.CTO });setUser(null);
This should give you enough to work with useState()
. As we focus on the minimal TypeScript skills for React we won’t discuss the other hooks here. Just note that useEffect()
doesn’t need typing.
A custom hook is again just a function. So you already know how to type it.
function useFireUser(firstName: string) {const [isFired, setIsFired] = useState(false);const hireAndFire = () => setIsFired(!isFired);return {text: isFired ? `Oops, hire ${firstName} back!` : "Fire this loser!",hireAndFire,};}function UserProfile({ firstName, role }: UserProfileProps) {const { text, hireAndFire } = useFireUser(firstName);return (<><div>Hi {firstName}, you suck!</div><button onClick={hireAndFire}>{text}</button></>);}
Working with inline click handlers is simple as TypeScript knows the type of the event parameter already. You don’t need to type anything manually.
function FireButton() {return (<button onClick={(event) => event.preventDefault()}>Fire this loser!</button>);}
It becomes more complicated when you create separate click handlers though.
function FireButton() {const onClick = (event: ???) => {event.preventDefault();};return (<button onClick={onClick}>Fire this loser!</button>);}
What’s the type of event
here? Here are two approaches:
Happy copy & pasting. You don’t even need to understand what’s going on here (hint: these are generics as we saw them with useState
).
function FireButton() {const onClick = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {event.preventDefault();};return (<button onClick={onClick}>Fire this loser!</button>);}
What about a change handler on an input? The same strategy reveals:
function Input() {const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {console.log(event.target.value);};return <input onChange={onChange} />;}
And a select?
function Select() {const onChange = (event: React.ChangeEvent<HTMLSelectElement>) => {console.log(event.target.value);};return <select onChange={onChange}>...</select>;}
As we’re all fans of component composition we need to know how to type the common children
prop.
type LayoutProps = {children: React.ReactNode;};function Layout({ children }: LayoutProps) {return <div>{children}</div>;}
The type React.ReactNode
gives you a lot of freedom. It basically lets us pass anything as a child (except an object).
If we want to be a bit stricter and only allow markup we can use React.ReactElement
or JSX.Element
(which is basically the same).
type LayoutProps = {children: React.ReactElement; // same as JSX.Element};
As you can see, this is much more restrictive:
Nowadays, many third-party libraries already ship with their corresponding types. In that case, you don’t need to install a separate package.
But many types are also maintained in the DefinitelyTyped repository on GitHub and published under the @types
organization (even the React types). If you install a library without types you get an error message on the import statement.
You can simply copy & paste the highlighted command and execute it in the terminal.
npm i --save-dev @types/styled-components
Most of the time, you’ll be lucky and the required types are available in some way or the other. Especially for more popular libraries. But if you’re not you can still define your own global types in a .d.ts
file (we won’t cover this here though).
Libraries often need to handle a lot of use cases. And thus they need to be flexible. To define flexible types generics are used. We’ve seen them with useState
already. Here’s a reminder:
const [names, setNames] = useState<string[]>([]);
This is very common in many third-party libraries. Here is Axios as an example:
import axios from "axios"async function fetchUser() {const response = await axios.get<User>("https://example.com/api/user");return response.data;}
Or react-query:
import { useQuery } from "@tanstack/react-query";function UserProfile() {// generic types for data and errorconst { data, error } = useQuery<User, Error>(["user"], () => fetchUser());if (error) {return <div>Error: {error.message}</div>;}...}
Or styled-components:
import styled from "styled-components";// generic type for propsconst MenuItem = styled.li<{ isActive: boolean }>`background: ${(props) => (props.isActive ? "red" : "gray")};`;function Menu() {return (<ul><MenuItem isActive>Menu Item 1</MenuItem></ul>);}
Creating a new project with TypeScript is the easiest option. I recommend either creating a Vite + React + TypeScript or Next.js + TypeScript project.
// for Vite run this command and select "react-ts"npm create vite@latest// for Next.js runnpx create-next-app@latest --ts
This will set you up completely automatically.
We’ve touched on that already but let me repeat it here: When in doubt (especially useful with events) just start typing an inline function and let TypeScript show you the correct type.
You can do the same if you’re not sure how many parameters are available. Just write (...args) =>
and you get all parameters in an array.
The easiest way of seeing all available fields on a type is by using the autocomplete feature of your IDE. Here by pressing CTRL + space (Windows) or Option + space (Mac).
Sometimes you need to dig deeper though. This is simple (yet often confusing) by CTRL + click (Windows) or CMD + click (Mac) to go to the type definitions.
One major problem when getting started with TypeScript is all the errors you encounter. All the things that can go wrong can drive you nuts. So it’s a good idea to get used to read the error messages. Let’s take this as an example:
function Input() {return <input />;}function Form() {return (<form><Input onChange={() => console.log("change")} /></form>);}
You might see the problem already. Still, here is the error that TypeScript shows.
Time to get confused! What the heck does this mean? What is the type IntrinsicAttributes
? When working with libraries (e.g. React itself) you’ll encounter many strange type names like this.
My advice: ignore them for now.
The most important piece of information is in the last line:
Property 'onChange' does not exist on type ...
Does that ring a bell? Look at the definition of the <Input>
component:
function Input() {return <input />;}
It doesn’t have a prop called onChange
. That’s what TypeScript is complaining about.
Now, this was a rather simple example. But what about this?
const MenuItem = styled.li`background: "red";`;function Menu() {return <MenuItem isActive>Menu Item</MenuItem>;}
Holy cow! It’s easy to be confused by the sheer amount of error output here. My favorite technique here is to scroll to the bottom of the message. More often than not the golden nugget is buried in there.
function UserProfile() {const { data, error } = useQuery(["user"],{cacheTime: 100000,},() => fetchUser());}
In the above example, the user data would usually come from an API. Let’s assume we have the User
type defined outside of the component
export type User = {firstName: string;role: UserRole;}
The UserProfile
component at the moment takes exactly this object as props.
function UserProfile({ firstName, role }: User) {...}
This might seem reasonable for now. And it’s in fact pretty easy to render this component when we have a ready-made user object.
function UserPage() {const user = useFetchUser();return <UserProfile {...user} />;}
But as soon as we want to add additional props that are not included in the User
type we make our lives harder. Remember the fireUser
function from above? Let’s put it to use.
function UserProfile({ firstName, role, fireUser }: User) {return (<><div>Hi {firstName}, you suck!</div><button onClick={() => fireUser({ firstName, role })}>Fire this loser!</button></>);}
But since the fireUser
function isn’t defined on the User
type we get an error.
To create the correct type we can use a so-called intersection type by combining two types with &
. This basically takes all fields from two different types and merges them into a single type.
type User = {firstName: string;role: UserRole;}// this is called an intersection// UserProfileProps has all fields of both typestype UserProfileProps = User & {fireUser: (user: User) => void;}function UserProfile({ firstName, role, fireUser }: UserProfileProps) {return (<><div>Hi {firstName}, you suck!</div><button onClick={() => fireUser({ firstName, role })}>Fire this loser!</button></>);}
Instead of intersection types, it’s often cleaner to separate the types. In our case, we can use a user prop instead of directly accepting all the user fields.
type User = {firstName: string;role: UserRole;}type UserProfileProps = {user: User;fireUser: (user: User) => void;}function UserProfile({ user, onClick }: UserProfileProps) {return (<><div>Hi {user.firstName}, you suck!</div><button onClick={() => fireUser(user)}>Fire this loser!</button></>);}